ChatGPTのストリーミング(SSE)APIを試してみた(Go実装)

ChatGPTのストリーミング(SSE)APIを試してみた(Go実装)

Clock Icon2023.03.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

ChatGPTはOpenAI社によって開発され、APIはOpenAI APIで利用可能です。基礎的な使い方は以下が参考になります。

chat.openai.comでは、入力中であることが分かるように、タイピングインジケータが表示されます。APIでもこの体験ができないか調べてみたところCreate chat completionエンドポイントでは、streamオプションがあったので試してみました。

最終的にはGoでハンドリングするコードを書いたですが、少しはまったため記事にすることにしました。

追記: 他にもいくつか方法があり、net/httpでも書くことが可能です。詳細は以下を参照ください。

GoとServer-Sent Events

前提

  • OpenAI APIに登録済み
  • APIキー作成済み
  • サンプルはOrganization IDを指定しないので請求先が異なる場合指定してリクエストしてください
  • /v1/chat/completionsにフォーカスした内容です。他のエンドポイントでサポートされているかはAPI referenceを参照してください

curlで試してみる

APIのBodyメッセージにstream=trueを渡すと、SSE(Server-Sent Event)でメッセージがストリーミングで配信されます。 指定可能なmodelは、Model endpoint compatibility/v1/chat/completionsを参考にしてください。

export OPENAI_API_KEY=<OpenAPIのAPIキー>
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "stream": true,
    "messages": [{"role": "user", "content": "Server-Sent Eventについて教えて"}]
  }'

以下のように細かい単位でレスポンスがストリーミング配信されます。

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"\n\n"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"Server"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"-S"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ent"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":" Event"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"("},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"S"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"SE"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":")"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"は"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"、"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"Web"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ブ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ラ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ウ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ザ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"が"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"サ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ーバ"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB","object":"chat.completion.chunk","created":1679424361,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"ー"},"index":0,"finish_reason":null}]}

(中略)

data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"提"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"供"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"します"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"。"},"index":0,"finish_reason":null}]}

data: {"id":"chatcmpl-6wbldvgCTzIsGk8fyvmBzV8cHSjlg","object":"chat.completion.chunk","created":1679426221,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}

data: [DONE]

JSONの構造を詳しくみてみます。初回はrole=assistantが返却されます。

{
  "id": "chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB",
  "object": "chat.completion.chunk",
  "created": 1679424361,
  "model": "gpt-3.5-turbo-0301",
  "choices": [
    {
      "delta": {
        "role": "assistant"
      },
      "index": 0,
      "finish_reason": null
    }
  ]
}

APIリクエストとレスポンスにある、roleの説明は以下の通りです。

role 説明
system システムメッセージに、役割やシナリオを伝え際に利用。必ずしも必要ではないと思っています。
user 実際のユーザーがモデルに問い合わせる質問や要求
assistant モデルが生成した回答
{
  "id": "chatcmpl-6wbHdXzs8Cgh4arATBF53vGOV1YmB",
  "object": "chat.completion.chunk",
  "created": 1679424361,
  "model": "gpt-3.5-turbo-0301",
  "choices": [
    {
      "delta": {
        "content": "\n\n"
      },
      "index": 0,
      "finish_reason": null
    }
  ]
}

最後には"[DONE]"が返却されます。JSON文字列ではないので注意です。

Goで試してみる

今回はgithub.com/r3labs/sseを利用したのですが、Feature Request: Support non-GET method and request body in the sse clientにある通り、非GETメソッド時にRequest Bodyが指定できないようです。本Issueでパッチ導入済みのforkgithub.com/munisystem/sse(revision: b0476d1)を利用します。(Issue作成者様本当にありがとうございます。。)

Go Modulesはforkを利用する場合、go.modのreplaceを利用して、相対パスで参照させます。

ghq get github.com/munisystem/sse
module github.com/shuntaka9576/sse-sample

go 1.19

require github.com/r3labs/sse/v2 v2.10.0

require (
	golang.org/x/net v0.0.0-20191116160921-f9c825593386 // indirect
	gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)

replace github.com/r3labs/sse/v2 => ../../../github.com/munisystem/sse
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"

	"github.com/r3labs/sse/v2"
)

type Choice struct {
	Delta struct {
		Content string `json:"content"`
	} `json:"delta"`
	Index        int         `json:"index"`
	FinishReason interface{} `json:"finish_reason"`
}

type JSONData struct {
	ID      string   `json:"id"`
	Object  string   `json:"object"`
	Created int64    `json:"created"`
	Model   string   `json:"model"`
	Choices []Choice `json:"choices"`
}

const APIKey = "<OpenAPIのAPIキー>"
const APIEndpoint = "https://api.openai.com/v1/chat/completions"

type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type requestBody struct {
	Model    string    `json:"model"`
	Stream   bool      `json:"stream"`
	Messages []Message `json:"messages"`
}

type customTransport struct {
	http.RoundTripper
}

func main() {
	client := &http.Client{
		Transport: &customTransport{
			RoundTripper: http.DefaultTransport,
		},
	}

	messages := []Message{
		{Role: "user", Content: "Server-Sent Eventについて教えて"},
	}


	body := requestBody{
		Messages: messages,
		Model:    "gpt-3.5-turbo",
		Stream:   true,
	}
	jsonData, err := json.Marshal(body)
	if err != nil {
		fmt.Println("Error marshaling request body:", err)
		os.Exit(1)
	}

	sseClient := sse.NewClient(APIEndpoint)
	sseClient.Connection = client
	sseClient.Method = "POST"
	sseClient.Body = bytes.NewBuffer([]byte(jsonData))
	sseClient.SubscribeRaw(func(msg *sse.Event) {
		var jsonData JSONData
		err := json.Unmarshal([]byte(msg.Data), &jsonData)
		if err != nil {
			fmt.Println(err)
			return
		}

		fmt.Printf("%s", jsonData.Choices[0].Delta.Content)
	})

}

func (t *customTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", APIKey))
	req.Header.Set("Content-Type", "application/json")

	resp, err := t.RoundTripper.RoundTrip(req)
	if err != nil {
		return nil, err
	}

	return resp, err
}

実行すると以下の通りです。 img

最後の方クラッシュしていますが、ストリーミングの最後は[DONE]文字列がmsg.Dataとして返却されるため、JSONへデシリアライズで失敗しています。文字列判定してからデシリアライズするのが良いと思います。 またsseのイベントをGoのチャンネルに送信するオプションもあるので、ソースコードの見通し良くする際に使えると思います。

さいごに

Goの実装例はより良い方法を知っている方いましたらご教授頂けますと幸いです。他の言語だともっとすんなりいくと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.